// Copyright 2012 Google Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

/* 
	Package galleryplus implements a web application that search the Google+ public stream for Albums with specific #hashtags or keywords, allow administrators to moderate them, and serve a JSONP feed of moderated entry.

	It runs on Google App Engine and expose the following urls:

	/usage
		Display an help page with links to usage examples

	/moderation
		Display moderation user interface

	/moderate
		Flag a list of albums as moderated

	/jsonp
		Display a feed of moderated album activities using JSONP format

	/fetch
		Fetch new images from Google+

	Author: proppy@google.com (Johan Euphrosine)
*/
package galleryplus

import (
	"appengine"
	"appengine/datastore"
	"appengine/memcache"
	"appengine/taskqueue"
	"appengine/urlfetch"
	"bytes"
	"fmt"
	"net/http"
	"encoding/json"
	"strconv"
	"strings"
	"text/template"
	"time"
	"net/url"

	"code.google.com/p/google-api-go-client/plus/v1"
)

// Configuration of the application with Google+ API key
type Config struct {
	key string
}

// Datastore entity representing an album submitted for Google Photography Prize
type AlbumActivityJson struct {
	Json      []byte         // original Google+ activity, marshalled in JSON
	Flag      string         // either new (not moderated), ok (accepted), or ko (rejected)
	Tag       string         // category in the form of a gpp hashtag, example: #megpp, travelgpp, ...
	Published time.Time // Date of publication
}

// Decoded album activity
type AlbumActivity struct {
	*plus.Activity                                       //  Google+ activity object
	*AlbumActivityJson                                   // corresponding datastore entity
	Album              *plus.ActivityObjectAttachments   // album attachment object
	Featured           *plus.ActivityObjectAttachments   // first photo of the album
	Photos             []*plus.ActivityObjectAttachments // photos contained in the album
}

// Template function to build an AlbumActivity object from the original activity stored in the datastore,
func album(ajson *AlbumActivityJson) *AlbumActivity {
	var aplus plus.Activity
	json.Unmarshal(ajson.Json, &aplus)
	alb := AlbumActivity{&aplus, ajson, nil, nil, nil}
	alb.Photos = []*plus.ActivityObjectAttachments{}
	for i, att := range aplus.Object.Attachments {
		if alb.Album == nil && att.ObjectType == "photo-album" { // first photo-album
			alb.Album = att
			alb.Photos = append(alb.Photos, aplus.Object.Attachments[:i]...)
			alb.Photos = append(alb.Photos, aplus.Object.Attachments[i+1:]...)
		}
		if att.ObjectType == "photo" { // last photo
			alb.Featured = att
		}
	}
	return &alb
}

// Template function map
var tmplFuncs = template.FuncMap{"album": album, "join": strings.Join}

// Return an http handler associated to a new App Engine context and with error logging.
func appHandler(f func(c appengine.Context, w http.ResponseWriter, r *http.Request) error) func(w http.ResponseWriter, r *http.Request) {
	return func(w http.ResponseWriter, r *http.Request) {
		c := appengine.NewContext(r)
		err := f(c, w, r)
		if err != nil {
			c.Errorf(err.Error())
			http.Error(w, err.Error(), http.StatusInternalServerError)
		}
	}
}

// web application mapping from url to application handlers.
func init() {
	http.HandleFunc("/usage", usage)
	http.HandleFunc("/moderation", appHandler(moderation))
	http.HandleFunc("/moderate", appHandler(moderate))
	http.HandleFunc("/jsonp", appHandler(jsonp))
	http.HandleFunc("/fetch", appHandler(fetch))
}

// templates
var templates = template.Must(template.New("galleryplus").Funcs(tmplFuncs).ParseFiles("templates/usage.html", "templates/moderation.html", "templates/jsonp.js"))

// Render the usage page
func usage(w http.ResponseWriter, r *http.Request) {
	if err := templates.ExecuteTemplate(w, "usage.html", nil); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
	}
}

// Render the moderation form
func moderation(c appengine.Context, w http.ResponseWriter, r *http.Request) error {
	// extract query parameters
	limit, _ := strconv.Atoi(r.FormValue("limit"))
	offset, _ := strconv.Atoi(r.FormValue("offset"))
	order := r.FormValue("order")
	// Default limit to 20
	if limit == 0 {
		limit = 20
	}
	// Order by less recent publication timeby default
	if len(order) == 0 {
		order = "Published"
	}
	// get unmoderated (new) AlbumActivity Entities
	q := datastore.NewQuery("AlbumActivityJson").
		Filter("Flag =", "new").
		Limit(limit).Offset(offset).
		Order(order)
	activitites := make([]AlbumActivityJson, 0, 20)
	if _, err := q.GetAll(c, &activitites); err != nil {
		return err
	}
	// count number of activities left to moderate
	count, err := datastore.NewQuery("AlbumActivityJson").
		Filter("Flag =", "new").
		Count(c)
	if err != nil {
		return err
	}
	// render moderation HTML template
	var data = struct {
		Count      int
		Activities interface{}
		Referer    string
	}{count, activitites, r.URL.RequestURI()}
	if err := templates.ExecuteTemplate(w, "moderation.html", &data); err != nil {
		return err
	}
	return nil
}

// Form action that flag a list of album as moderated
func moderate(c appengine.Context, w http.ResponseWriter, r *http.Request) error {
	// store current time with "last_moderate" key in memcache
	time := []uint8(strconv.Itoa(time.Now().Nanosecond()))
	memcache.Set(c, &memcache.Item{Key: "last_moderate", Value: time})
	c.Infof("set last_moderate: %v", string(time))
	// parse moderated album from form parameters
	if err := r.ParseForm(); err != nil {
		return err
	}
	size := len(r.Form["key"])
	keys := make([]*datastore.Key, 0, size)
	moderation := make([]string, 0, size)
	for _, k := range r.Form["key"] {
		keys = append(keys, datastore.NewKey(c, "AlbumActivityJson", k, 0, nil))
		m := "ok"
		if len(r.Form[k]) > 0 {
			m = "ko"
		}
		moderation = append(moderation, m)
	}
	// get corresponding datastore entities
	entities := make([]interface{}, size)
	for i, _ := range entities {
		entities[i] = new(AlbumActivityJson)
	}
	if err := datastore.GetMulti(c, keys, entities); err != nil {
		return err
	}
	for _, k := range keys {
		c.Infof("fetched: %s", k)
	}
	// update the flag properties of moderated entitoes
	for i, entity := range entities {
		activity, ok := entity.(*AlbumActivityJson)
		if !ok {
			c.Infof("type asserted failed")
		}
		activity.Flag = moderation[i]
	}
	if _, err := datastore.PutMulti(c, keys, entities); err != nil {
		return err
	}
	// redirect to moderation user interface
	referer := r.Referer()
	if len(referer) == 0 {
		referer = r.FormValue("referer")
		c.Infof("Workaround missing Referer header: %s", referer)
	}
	http.Redirect(w, r, referer, 302)
	return nil
}

// Display a JSONP feed of moderated entities using original Google+ JSON activities format
func jsonp(c appengine.Context, w http.ResponseWriter, r *http.Request) error {
	// extract query parameters
	tag := r.FormValue("tag")
	limit, _ := strconv.Atoi(r.FormValue("limit"))
	offset, _ := strconv.Atoi(r.FormValue("offset"))
	order := r.FormValue("order")
	callback := r.FormValue("callback")
	// order by most recent publication time by default
	if len(order) == 0 {
		order = "-Published"
	}
	// default JSONP callback is "jsoncallback"
	if len(callback) == 0 {
		callback = "jsonpcallback"
	}
	// try to get the album feed for this query from memcache
	var k string
	if item, err := memcache.Get(c, "last_moderate"); err == nil {
		c.Infof("last_moderate: %v", string(item.Value))
		k = r.URL.RawQuery + string(item.Value)
		if item, err := memcache.Get(c, k); err == nil {
			c.Infof("cache hit: %v", item.Key)
			w.Header().Set("Content-Type", "application/javascript")
			w.Write(item.Value)
			return nil
		}
	}
	// get moderated album entities corresponding to query parameters from the datastore
	q := datastore.NewQuery("AlbumActivityJson").
		Filter("Flag =", "ok").
		Limit(limit).
		Offset(offset).
		Order(order)
	if len(tag) > 0 {
		q = q.Filter("Tag =", tag)
	}
	// extract corresponding Google+ JSON actitivies
	activitiesJson := make([]string, 0, limit)
	for t := q.Run(c); ; {
		var a AlbumActivityJson
		_, err := t.Next(&a)
		if err == datastore.Done {
			break
		} else if err != nil {
			return err
		}
		activitiesJson = append(activitiesJson, string(a.Json))
	}
	c.Infof("len activitities: %i %i", len(activitiesJson), limit)
	// render the JSONP feed template
	w.Header().Set("Content-Type", "application/javascript")
	var buf bytes.Buffer
	var data = struct {
		Callback       string
		ActivitiesJson []string
	}{callback, activitiesJson}
	if err := templates.ExecuteTemplate(&buf, "jsonp.js", &data); err != nil {
		return err
	}
	// store the JSON feed in memcache
	bs := buf.Bytes()
	w.Write(bs)
	if len(k) > 0 {
		c.Infof("set cache: %v", k)
		memcache.Set(c, &memcache.Item{Key: k, Value: bs})
	}
	return nil
}

// Create an http.Client that use App Engine urlfetch API and inject Google+ API key
func httpClient(c appengine.Context) *http.Client {
	transport := Transport{ApiKey: config.key, http.RoundTripper: &urlfetch.Transport{Context: c}}
	return &http.Client{Transport: &transport}
}

// Fetch new album activities to moderate from Google+
func fetch(c appengine.Context, w http.ResponseWriter, r *http.Request) error {
	// Extract query parameters
	tag := r.FormValue("tag")
	nextPageToken := r.FormValue("nextPageToken")
	// Search for Google+ activities matching the supplied tag
	service, err := plus.New(httpClient(c))
	if err != nil {
		return err
	}
	result, err := service.Activities.Search(tag).PageToken(nextPageToken).Do()
	if err != nil {
		return err
	}
	// Only store activities with album attached to the datastore
	for _, item := range result.Items {
		for _, att := range item.Object.Attachments {
			if att.ObjectType == "photo-album" {
				published, _ := time.Parse(time.RFC3339, item.Published)
				json, _ := json.Marshal(item)
				a := AlbumActivityJson{Json: json, Flag: "new", Tag: tag, Published: published}
				// Check datastore for duplicate album before storing it
				k := datastore.NewKey(c, "AlbumActivityJson", att.Url, 0, nil)
				err = datastore.Get(c, k, &a)
				if err == datastore.ErrNoSuchEntity {
					datastore.Put(c, k, &a)
					c.Infof("new album %s: %s", att.DisplayName, att.Url)
				}
				break
			}
		}
	}
	// Continue to fetch album activities in the background using Task Queue API
	if len(result.NextPageToken) > 0 {
		t := taskqueue.NewPOSTTask("/fetch", url.Values{
			"nextPageToken": {result.NextPageToken},
			"tag":           {tag}})
		taskqueue.Add(c, t, "")
	}
	fmt.Fprintf(w, "fetching %s in the background", tag)
	return nil
}
